这是「从零搭建 Agent」系列的第三篇。到这里,Agent 已经不只是会调用
calculator的教学玩具了,它可以读文件、写文件、跑命令、搜网页、抓正文,也可以在不同模型接口之间切换。
但能力变强之后,新的问题出现了:工具输出、命令日志、网页正文、错误信息、历史对话都会不断进入 conversation history。模型每一轮到底应该看到什么?哪些内容应该保留?哪些内容应该截断?哪些内容应该总结?这就是本章要实现的 Context Engine。
同步项目地址 https://github.com/Tritium0041/Singularity,当前进度位于 https://github.com/Tritium0041/Singularity/commit/0a33a7a99bccab142129235be1ac6cdf36a748fa
# 为什么现在需要 Context Engine?
第 2 章中实现的 Agent Loop 其实已经形成了一个闭环,它每一轮做的事情很清楚:
User input | |
-> append user message | |
-> call LLM | |
-> assistant may call tools | |
-> execute tools | |
-> append tool results | |
-> next turn |
在代码里,当前上下文实现就只是简单地把完整消息历史交给模型:
const rawRequest: LlmRequest = { | |
model: this.model, | |
systemPrompt: this.systemPrompt, | |
messages: [...this.messages], | |
tools: this.tools.toLlmToolSpecs(), | |
reasoning: options.reasoning ?? this.reasoning, | |
signal: options.signal | |
}; |
在只有 calculator 和 get_weather 这样的 mock 工具的时候,这么做没什么问题。历史消息很短,工具结果也很短,模型每一轮看到完整 history 反而最简单。
但在第 2.5 章之后,我们新增了文件、Shell、Web/Search 这些工具,Agent 开始接触真实环境。真实环境里的 observation 往往不是一句话,而是一大块东西:
read_file可能读到几千行源代码。execute_command可能吐出一整屏构建日志。fetch_url可能抓回一篇很长的网页正文。- 工具失败时,stderr、exit code、timeout 信息也要进入上下文。
这时候如果继续把完整 history 原样塞给模型,就会遇到三个问题:
第一个问题是上下文窗口爆炸。即使我们在工具层已经做了 head/tail truncation,长任务里这些 “截断后的工具结果” 仍然会继续累积,迟早把模型窗口撑满。
第二个问题是注意力漂移。模型看到的信息越多,不一定越聪明。有时它会被旧日志、旧错误、旧网页内容带偏,忘记当前真正要做什么。
第三个问题是成本和缓存。第 1 章里说过,Harness 很重要的一部分是缓存友好的上下文分层。稳定内容越靠前,动态内容越靠后,越容易命中 prompt cache。把所有东西混成一条不断变长的消息数组,会让后续优化很难做。
所以,我们需要一个统一的 Context Engine 对历史消息进行整理,在 Agent Loop 将消息发送给 LLM 之前,多加一道生成 “模型可见视图” 的处理层:
完整 Agent history | |
-> Context Engine | |
-> system prompt fragments | |
-> token estimate and budget | |
-> request-view tool truncation | |
-> history compaction | |
-> dynamic compression | |
-> LlmRequest | |
-> Provider adapter |
Agent 自己的 this.messages 是事实记录,不能随便破坏;模型看到的 request.messages 是工作视图,可以被截断、压缩、替换和重排。这两个实体的分离,是本章最重要的设计。
# 从 Codex 和 Pi 学到什么?
Codex 和 Pi 的基本思路实际都是差不多的,例如把历史消息和模型上下文分离、Token 估算、动态构造初始 context,以及基于 Fork Agent 的 LLM summary。
我们也把这些思路当作本章的基本路径:
- 不追求精确 tokenizer,用稳定、低成本的估算代替。
- 保留最近上下文,因为最近的工具结果通常最关键,且能为下一步动作提供指导。
- 老上下文不能直接被切除,要变成 summary,保留用户目标。
- 压缩只作用于 request-view context,默认不破坏完整 history。
于是我们在 src/context/ 下新增了一组模块,作为我们的 Context Engine 实现:
src/context/ | |
token-estimator.ts | |
budget-manager.ts | |
prompt-builder.ts | |
history-compressor.ts | |
dynamic-compressor.ts | |
context-engine.ts | |
types.ts |
# 第一层:Prompt Builder,把稳定提示词构建起来
第 2 章时, systemPrompt 还是一个简单字符串。用户传什么,我们就发什么。
第三章之后,system prompt 开始承担更多职责。它不只是 “你是一个助手” 这种角色设定,还要包含:
- 默认 Agent 行为规则。
- 当前工作目录、日期、时区、shell。
- 当前可用工具列表。
- 用户传入的 user goal prompt。
这些内容变化频率不同。默认行为规则、工具列表通常比较稳定;日期、cwd、用户目标相对动态;当前任务状态更动态。为了后续 prompt cache 的稳定性,我们需要 PromptBuilder 做的事情很简单:把这些片段按稳定顺序拼起来。
export class PromptBuilder { | |
buildConversationSystemPrompt(options: SystemPromptBuilderOptions): string | undefined { | |
const fragments = [...(options.fragments ?? [])]; | |
const backgroundFragment = | |
options.background === false | |
? undefined | |
: this.buildBackgroundFragment(options.background ?? {}); | |
if (backgroundFragment) { | |
fragments.unshift(backgroundFragment); | |
} | |
const defaultInstructions = | |
options.defaultInstructions === false | |
? undefined | |
: options.defaultInstructions ?? DEFAULT_AGENT_INSTRUCTIONS; | |
return this.buildSystemPrompt( | |
defaultInstructions, | |
[toBasePromptFragment(options.basePrompt), ...fragments].filter(Boolean) as PromptFragment[] | |
); | |
} | |
} |
上下文背景会被渲染成结构化 XML 风格:
<environment_context> | |
<cwd>/path/to/project</cwd> | |
<current_date>2026-06-11</current_date> | |
<timezone>Asia/Shanghai</timezone> | |
<shell>zsh</shell> | |
</environment_context> | |
<available_tools> | |
<tool name="read_file">Read a UTF-8 file.</tool> | |
<tool name="execute_command">Execute a shell command.</tool> | |
</available_tools> |
这一设计是为了降低模型理解现状的成本,Context Engine 首先要实现的就应该是让模型每一轮都能稳定地看到 “自己是谁、在哪里、有什么工具”。
# 第二层:Token Usage,让上下文有共同度量
要做预算,首先要有计算当前上下文用量的能力。由于 Agent 可能对接不同模型,我们没有引入精确 tokenizer,而是用了经验公式近似取值:
export function estimateTextTokens(text: string | undefined): number { | |
if (!text) { | |
return 0; | |
} | |
return Math.ceil(text.length / 4); | |
} |
估算范围不能只看 message content,请求里所有类型的消息都会占上下文,所以完整的上下文估算方法 estimateRequestTokens() 会拆成三部分:
const systemPromptTokens = estimateTextTokens(request.systemPrompt); | |
const messageTokens = estimateMessagesTokens(request.messages); | |
const toolTokens = estimateToolSpecsTokens(request.tools); | |
const heuristicTotalTokens = systemPromptTokens + messageTokens + toolTokens; |
如果 Provider 已经返回 usage,我们的 Engine 也会根据回传的 provider usage 校准。上一轮结束后 provider 已经告诉我们 inputTokens 了,那这一轮就不用从零估算整段消息历史,只需要把上一轮之后新增的 Token 用量估算进去。
const providerEstimate = estimateFromProviderUsage(request.messages); | |
if (!providerEstimate) { | |
return heuristicEstimate; | |
} | |
return { | |
...heuristicEstimate, | |
totalTokens: providerEstimate.totalTokens, | |
source: "provider_usage", | |
providerInputTokens: providerEstimate.providerInputTokens, | |
providerOutputTokens: providerEstimate.providerOutputTokens, | |
appendedMessageTokens: providerEstimate.appendedMessageTokens | |
}; |
# 第三层:Budget Manager,决定什么时候压缩
有能力计算上下文用量之后,就需要一个预算器。
export const DEFAULT_CONTEXT_ENGINE_OPTIONS = { | |
enabled: true, | |
contextWindowTokens: 256000, | |
compactionThresholdRatio: 0.9, | |
reservedOutputTokens: 16000, | |
keepRecentTokens: 20000, | |
maxToolResultTokens: 20000, | |
summarizeHistory: true, | |
dynamicCompression: false | |
}; |
contextWindowTokens 是这一代模型上下文窗口的常用大小, reservedOutputTokens 是留给模型回复的空间, compactionThresholdRatio 是触发压缩的比例。
真正的触发线是两者取更保守的那个(本质是 codex 和 pi 用了不同的实现,一开始选的是抄 pi 的,后来改成了用 codex 的):
get compactionTriggerTokens(): number { | |
const thresholdTokens = Math.floor(this.options.contextWindowTokens * this.options.compactionThresholdRatio); | |
return Math.max(0, Math.min(thresholdTokens, this.availableInputTokens)); | |
} |
# 第四层:启发式上下文压缩
Context Engine 的第一层压缩先处理过长的 tool result 和模型输出。这一层压缩基本是用来 fallback 的,实际生产环境不太会用到。
如果整个 request 超过预算,且没有其他压缩模式可用,就会触发启发式压缩。压缩的基本策略是:
- 按 user message 切分 turns。
- 从最新 turn 倒序保留,直到达到
keepRecentTokens。 - 更早的 turns 不直接原样保留。
- 保留所有用户指令,丢弃旧 assistant/tool 细节。
代码里对应的是:
const turns = splitIntoTurns(messages); | |
const keptTurns: MessageTurn[] = []; | |
for (let index = turns.length - 1; index >= 0; index -= 1) { | |
const turn = turns[index]; | |
const turnTokens = estimateMessagesTokens(turn.messages); | |
if (keptTurns.length > 0 && keptTokens + turnTokens > this.options.keepRecentTokens) { | |
break; | |
} | |
keptTurns.unshift(turn); | |
keptTokens += turnTokens; | |
} |
最近上下文通常包含当前正在处理的文件、刚失败的命令、刚返回的 tool result。它最应该被完整保留。更早的内容里,最重要的是用户目标和约束,而不是每一轮旧工具输出的细枝末节。
所以默认启发式压缩会保留早期 user message:
function selectUserInstructionMessages(messages: readonly AgentMessage[]): AgentMessage[] { | |
return messages | |
.filter((message) => message.role === "user" && !isHandoffSummaryMessage(message.content)) | |
.map((message) => ({ role: "user" as const, content: message.content })); | |
} |
# 第五层:Handoff Summary,让旧历史变成交接记录
纯启发式压缩有点过于粗糙了。它会保留用户指令,却不一定能保留旧工具执行中真正重要的事实。比如早期 turn 里读过某个文件,发现一个关键函数;或者跑过一次测试,得到一个具体错误。只保留用户消息就导致上下文失真了。
参照 Codex,Context Engine 支持 model handoff compaction。压缩时,它会构造一个 summary request,让 fork 出的 worker 生成一段交接摘要:
private buildSummaryRequest(request: LlmRequest): LlmRequest { | |
return { | |
...request, | |
model: this.options.compressionModel ?? request.model, | |
reasoning: this.options.compressionReasoning ?? request.reasoning, | |
messages: [ | |
...request.messages, | |
{ | |
role: "user", | |
content: CONTEXT_HANDOFF_SUMMARY_PROMPT | |
} | |
], | |
tools: [] | |
}; | |
} |
summary request 中,发送给模型的请求不带 tools 选项,压缩模型的任务只做把旧上下文整理成下一轮能接住的摘要,不做任何额外操作。这一步可能会导致缓存匹配失效,可以在后期实验优化。
压缩模型也可以和主模型不同:
const agent = new Agent({ | |
llm: mainLlm, | |
model: "main-model", | |
compressionLlm: cheapLlm, | |
compressionModel: "small-summary-model" | |
}); |
这给后续成本优化留下了空间。主模型负责推理和工具调用,便宜模型负责上下文交接。
生成的摘要会以普通 user message 注入 request view:
Context checkpoint summary for the next model: | |
... |
它不是伪装成 assistant 的旧回答,也不是偷偷塞进 system prompt,而是明确告诉模型:这是给下一轮恢复任务用的 checkpoint。
如果是手动调用:
await agent.compactHistory(); |
则可以把压缩后的 messages 写回 Agent history。自动压缩默认只影响本轮 request view,不破坏完整历史。
完成后的 summary 会和系统提示词、历史所有用户消息、最近几轮调用一起拼回上下文中:
[ | |
...oldUserMessagesFromCompactedPrefix, | |
{ | |
role: "user", | |
content: "Context checkpoint summary for the next model:\n" + summaryText | |
}, | |
...keptRecentTurns | |
] |
# 第六层:Dynamic Compression,让模型自己触发上下文折叠
上面的 handoff compaction 是 “一次性压缩”。它适合上下文快爆了的时候做一次急救。
另一种上下文压缩的方式是动态维护一组可复用 summary block:哪些旧目标的消息已经闭环,可以折叠;哪些还在当前工作路径上,必须保留。这个判断靠固定规则并不好做,因为只有模型知道当前任务真正依赖哪些上下文。
所以本章还实现了一个实验性的 dynamic compression。
开启后,Context Engine 会给 request 注入一套压缩协议,并可选暴露一个工具:
compact_context |
这个工具不会直接让主模型手写 summary。它会启动一个 side worker,让压缩模型从当前可见上下文中选择已经闭合的范围,生成 summary block,然后把这个 block 安装到动态压缩状态里。
后续 request 会把对应旧消息替换成:
Dynamic context summary: | |
... |
内部状态大概长这样:
export type DynamicCompressionBlock = { | |
id: number; | |
startIndex: number; | |
endIndex: number; | |
messageCount: number; | |
coveredMessageIds?: string[]; | |
coveredToolCallIds?: string[]; | |
summary: string; | |
summaryTokens: number; | |
topic?: string; | |
source?: "auto" | "tool" | "worker"; | |
}; |
这里最重要的是 coveredToolCallIds 和 message fingerprints。动态压缩不是简单把一段文本替换掉,它还要知道这个 summary 覆盖了哪些原始消息、哪些工具调用,以及后续是否还能复用。
当当前上下文达到了最大限度的 50% 时,模型接收到的系统提示词会多一句(发现问题了吗)
<context_compression_nudge> | |
Estimated context is at or above ... tokens. | |
Before continuing, call compact_context ... | |
</context_compression_nudge> |
模型会自己选择合适的时机调用这一工具,freeze 自身并触发 worker 进行 block summary,对当前上下文中选定的部分进行替换。
# Context Engine 的完整数据流
把上面几层合起来, ContextEngine.prepare() 的流程大概是:
raw LlmRequest | |
-> buildRequestView | |
-> build stable system prompt | |
-> truncate oversized tool results | |
-> estimate request tokens | |
-> use provider usage if available | |
-> fallback to heuristic estimate | |
-> dynamic compression prepare | |
-> inject protocol | |
-> project active summary blocks | |
-> maybe generate new block | |
-> check budget | |
-> if under threshold: return request view with metadata | |
-> if over threshold: compact | |
-> model handoff summary if available | |
-> otherwise heuristic compaction | |
-> final LlmRequest |
代码结构也基本按这个顺序:
private prepareInternal(request: LlmRequest, summarize?: SummaryModel) { | |
if (!this.options.enabled) { | |
return { request }; | |
} | |
const requestWithTruncatedTools = this.buildRequestView(request); | |
const baseEstimate = estimateRequestTokens(requestWithTruncatedTools); | |
const dynamicResult = this.dynamicCompressor.prepare( | |
requestWithTruncatedTools, | |
baseEstimate, | |
summarize | |
); | |
return this.finishPreparedRequest( | |
dynamicResult.request, | |
dynamicResult.applied, | |
summarize, | |
"automatic" | |
); | |
} |
最后返回的 LlmRequest 会带上 context metadata:
metadata: { | |
context: { | |
compacted, | |
estimatedInputTokens, | |
tokenEstimateSource, | |
compactionDecisionEstimatedInputTokens, | |
compactionSummarySource, | |
estimate, | |
compaction, | |
dynamicCompression | |
} | |
} |
这部分 metadata 是事件流的一部分。UI、CLI、日志系统可以知道这一轮有没有压缩、估算来源是什么、压缩前后 token 大概是多少。
# 接入 Agent Loop
将 Context Engine 接入 Agent Loop 的改动很小。原来 Agent 在每一轮直接调用 LLM:
const assistant = await this.completeAssistant(turn, rawRequest, streamEventSink); |
现在中间加一层:
const prepared = await this.prepareRequest(rawRequest, options.context); | |
const request = prepared.request; | |
const assistant = this.withRequestContext( | |
await this.completeAssistant(turn, request, streamEventSink), | |
request | |
); |
如果发生手动 history replacement,就更新 this.messages :
if (prepared.historyReplacement) { | |
this.messages.splice(0, this.messages.length, ...prepared.historyReplacement); | |
} |
否则,自动上下文压缩只改变本轮 request,不改变完整 history。
Agent 的消息记录承担了历史事实记录和当前模型输入两部分职责。前者应该尽量完整,后者应该尽量有效。把这两者分开,后续 Memory、Planner、Reflector 才有继续演化的空间。
# 这一章完成后,Agent 发生了什么变化?
第二章结束时,Agent 解答的核心问题是:
- 模型能不能调用工具?
- 工具结果能不能回填?
- 循环能不能终止?
第 2.5 章结束时,问题变成:
- Agent 能不能接触真实文件、命令和网页?
- 工具输出变长后,模型能力会不会失控?
第三章完成后,问题又往前走了一步:
- 当信息开始变多时,Agent 能不能决定模型应该看到什么?
当前进度下,我们的项目已经具备了 Harness 的第一块核心能力:在完整历史和模型输入之间,建立一个可控、可测、可扩展的上下文管理层,也给我们的 Agent 提供了几个核心能力。
第一,长任务不再只能靠 “把所有东西塞进去”。工具结果可以在 request view 中二次截断,旧历史可以变成 handoff summary,动态压缩可以把闭合上下文折叠成 block。
第二,模型更容易保持任务主线。早期用户目标会被保留,最近 turn 会优先保留,旧的噪声细节不会无限占据注意力。
第三,Provider usage 开始成为预算的一部分。我们不再只靠本地粗估,而是能把上一轮真实 input/output token 反馈进下一轮决策。
第四,系统 prompt 开始有了稳定分层。默认指令、环境上下文、工具信息、用户 prompt 不再混成一个随手拼接的字符串。
第五,可观测性有了入口。每轮请求可以带 context metadata,后续 UI 可以展示压缩前后差异、触发原因和 summary 调用成本。
# 下一章做什么?
Context Engine 解决的是 “当前任务里,哪些信息应该进入模型上下文”。但它还不足以支撑 Agent 完成一项真实情景中的长任务。
为了提供完成长任务的能力,我们在下一节中会开始构建并完善 Memory System,为超长上下文任务提供一套高召回率的证据链:
- 短期记忆(模型的 request-view 进行管理):当前 turn 和最近工具结果。
- 中期记忆(compact、summary 系统承担了一部分职责):当前任务计划、已读文件、已修改文件、重要错误。
- 长期记忆:用户偏好、项目约定、可复用知识。
到那时,Context Engine 会变成 Memory 的调度入口:它不只是压缩当前 history,还要决定从在已经进入冷存储的上下文中召回什么内容,放在 prompt 的什么位置,用多少 token。